spb/autoscholar
Public
1import { AppError, notFound } from "@autoscholar/core";2import { papers, paperVersions } from "@autoscholar/db";3import { publicArtifactKey } from "@autoscholar/storage";4import { and, eq } from "drizzle-orm";5import { handler } from "@/lib/server/api";6import { db, storage } from "@/lib/server/db";78export const runtime = "nodejs";910/** Public artifact delivery: only files listed in the published version's artifact set; immutable; integrity header. */11export const GET = handler(async (req, { params }) => {12 const { publicId, version, path } = (await params) as { publicId: string; version: string; path: string[] };13 const vn = Number(version.replace(/^v/, ""));14 if (!Number.isInteger(vn) || vn < 1) throw notFound("version");15 const rel = path.map(decodeURIComponent).join("/");16 if (rel.includes("..") || rel.startsWith("/")) throw new AppError("invalid", "bad path", 400);17 const d = db();18 const [row] = await d19 .select({ p: papers, v: paperVersions })20 .from(papers)21 .innerJoin(paperVersions, and(eq(paperVersions.paperId, papers.id), eq(paperVersions.versionNo, vn)))22 .where(eq(papers.publicId, publicId.toUpperCase()))23 .limit(1);24 if (!row) throw notFound("paper version");25 if (process.env.STAGING_MODE !== "1" && row.p.synthetic) throw notFound("paper version");26 if ((row.v.metadata as { artifactsWithdrawn?: boolean }).artifactsWithdrawn)27 return new Response("Artifacts withdrawn. See the paper page for the notice.", {28 status: 410,29 headers: { "content-type": "text/plain" },30 });31 const entry = row.v.artifactSet.find((a) => a.path === rel);32 if (!entry) throw notFound("artifact");33 const key = publicArtifactKey(row.p.publicId, vn, rel);34 const st = storage();35 const head = await st.head(key);36 if (!head) throw notFound("artifact object");37 if (head.sha256 !== entry.sha256) throw new AppError("integrity", "artifact integrity check failed", 500);38 const stream = await st.stream(key);39 const inline =40 /^(application\/pdf|image\/|text\/)/.test(entry.contentType) &&41 new URL(req.url).searchParams.get("download") !== "1";42 const filename = rel.split("/").pop() ?? "file";43 const disp = `${inline ? "inline" : "attachment"}; filename="${row.p.publicId}-v${vn}-${filename}"`;44 const webStream = (await import("node:stream")).Readable.toWeb(stream) as ReadableStream;45 return new Response(webStream, {46 headers: {47 "content-type": entry.contentType,48 "content-length": String(entry.bytes),49 "content-disposition": disp,50 "x-artifact-sha256": entry.sha256,51 "cache-control": "public, max-age=31536000, immutable",52 "accept-ranges": "none",53 ...(entry.contentType.startsWith("text/") || entry.contentType === "application/json"54 ? { "content-security-policy": "sandbox" }55 : {}),56 },57 });58});59